//@author A0087191Y
package net.mysoc.w111j.service;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;

import com.thoughtworks.xstream.converters.ConversionException;
import com.thoughtworks.xstream.io.StreamException;
import net.mysoc.w111j.io.UserXMLHelper;
import net.mysoc.w111j.model.BadCategoryNameException;
import net.mysoc.w111j.model.BadPaymentTypeNameException;
import net.mysoc.w111j.model.Category;
import net.mysoc.w111j.model.Expense;
import net.mysoc.w111j.model.Money;
import net.mysoc.w111j.model.PaymentType;
import net.mysoc.w111j.model.User;
import net.mysoc.w111j.service.actions.*;
import net.mysoc.w111j.service.filters.CategoryExpenseFilter;
import net.mysoc.w111j.service.filters.DateExpenseFilter;
import net.mysoc.w111j.service.filters.ExpenseFilter;
import net.mysoc.w111j.service.filters.PaymentTypeExpenseFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Service to expose expense-management functions.
 */
public class ExpenseService {
    private static Logger logger = LoggerFactory.getLogger(ExpenseService.class);
    private static final String DEFAULT_FILENAME = "Expenses.xml";
    private static final Path appDirectory = FileSystems.getDefault().getPath(System.getenv("APPDATA"), "EzXpns");
    private final String path;
    private final ReportService reportService;
    private final UndoManager undoManager;
    private User currentUser;

    /**
     * Constructs an {@code ExpenseService} using the default filename. A new file is created if it does not exist, or
     * if the file exists but is an invalid format, overwrites it.
     *
     * @throws IOException
     */
    public ExpenseService() throws IOException {
        this(DEFAULT_FILENAME, true, true);
    }

    /**
     * Constructs an {@code ExpenseService} using the specified parameters.
     *
     * @param filename         the file to load from and save to.
     * @param createIfNotExist whether to create a new file is it does not exist.
     * @param overwriteOnError whether to overwrite the file if it is invalid.
     * @throws IOException
     */
    public ExpenseService(String filename, boolean createIfNotExist, boolean overwriteOnError) throws IOException {
        path = appDirectory.resolve(filename).toString();
        try {
            loadUser(path);
        } catch (FileNotFoundException e) {
            if (createIfNotExist) {
                logger.debug("xtor: file not found");
                currentUser = initialiseNewUser();
                Files.createDirectories(appDirectory);
                saveUser();
            } else {
                throw e;
            }
        } catch (StreamException | ConversionException e) {
            logger.debug("xtor: error loading user");
            if (overwriteOnError) {
                currentUser = initialiseNewUser();
                Files.createDirectories(appDirectory);
                saveUser();
            } else {
                throw e;
            }
        } catch (IOException e) {
            throw e;
        }
        reportService = new ReportService();
        undoManager = new UndoManager();
    }

    /**
     * Initialises a new {@code User} object.
     *
     * @return the new {@code User} object.
     */
    private static User initialiseNewUser() {
        User user = new User();
        return user;
    }

    /**
     * Loads a {@code User} object from an XML file.
     *
     * @param path the path to the XMl file.
     * @throws IOException
     */
    private void loadUser(String path) throws IOException {
        currentUser = UserXMLHelper.loadUser(path);
    }

    /**
     * Saves the current {@code User} object to an XML file.
     */
    public void saveUser() {
        try {
            UserXMLHelper.saveUser(path, currentUser);
        } catch (IOException e) {
            // Swallow the exception
            e.printStackTrace();
        }
    }

    /**
     * Gets the current user's budget.
     *
     * @return the budget value.
     */
    public Money getBudget() {
        return currentUser.getBudget();
    }

    /**
     * Sets the current user's budget.
     */
    public void setBudget(Money amount) {
        logger.debug("setBudget: {}", amount);
        undoManager.addAction(new ChangeBudgetAction(currentUser, getBudget()));
        currentUser.setBudget(amount);
        saveUser();
    }

    /**
     * Gets the total expenditure from the start of the current month to today.
     *
     * @return the total expenditure for the current month.
     */
    public Money getCurrentMonthExpenditure() {
        logger.debug("getCurrentMonthExpenditure");
        List<Expense> expenses = getExpenses(new DateExpenseFilter(
                ReportService.getStartDateForCurrentPeriod(ReportType.MONTHLY), ReportService.getToday()));
        Money total = new Money();
        for (Expense e : expenses) {
            total = total.add(e.getPrice());
        }
        return total;
    }

    /**
     * Gets all the categories that the current user has.
     *
     * @return a list of user categories.
     */
    public List<Category> getAllCategories() {
        return currentUser.getCategories();
    }

    /**
     * Gets a {@code Category} given its name.
     *
     * @param name the category to look up.
     * @return the required {@code Category}.
     */
    public Category getCategoryByName(String name) {
        return currentUser.getCategoryByName(name);
    }

    /**
     * Gets all the payment types that the current user has.
     *
     * @return a list of user payment types.
     */
    public List<PaymentType> getAllPaymentTypes() {
        return currentUser.getPaymentTypes();
    }

    /**
     * Gets a {@code Category} given its name.
     *
     * @param name the category to look up.
     * @return the required {@code Category}.
     */
    public PaymentType getPaymentTypeByName(String name) {
        return currentUser.getPaymentTypeByName(name);
    }

    /**
     * Gets all the expenses that the current user has.
     *
     * @return a list of user expenses.
     */
    public List<Expense> getAllExpenses() {
        List<Expense> expenses = currentUser.getExpenses();
        // Sort in reverse chronological order, or by decreasing order of id when equal
        Collections.sort(expenses, new Comparator<Expense>() {
            @Override
            public int compare(Expense o1, Expense o2) {
                int dateCompare = -o1.getDate().compareTo(o2.getDate());
                if (dateCompare == 0) {
                    return o2.getId() - o1.getId();
                } else {
                    return dateCompare;
                }
            }
        });
        return expenses;
    }

    /**
     * Gets all the expenses that matched the specified {@code ExpenseFilter}.
     *
     * @param filter the filter to use.
     * @return a list of matching user expenses.
     */
    public List<Expense> getExpenses(ExpenseFilter filter) {
        List<Expense> filtered = new LinkedList<>();
        for (Expense e : getAllExpenses()) {
            if (filter.shouldSelect(e)) {
                filtered.add(e);
            }
        }
        return filtered;
    }

    /**
     * Gets the expense with the specified id.
     *
     * @param id the id of the expense to get.
     * @return the expense.
     */
    public Expense getExpense(int id) {
        return currentUser.getExpense(id);
    }

    /**
     * Adds or updates a {@code Category} with new information. User {@code NO_ID} as the id when adding, or the
     * existing id when updating.
     *
     * @param category the {@code Category} to be added or updated.
     * @return the added or updated {@code Category}.
     * @throws BadCategoryNameException when another category with the same name exists.
     */
    public void updateCategory(Category category) throws BadCategoryNameException {
        logger.debug("updateExpense: {}", category);
        Action action;
        boolean isNew = category.getId() == User.NO_ID;
        if (isNew) {
            category = currentUser.updateCategory(category);
            action = new AddCategoryAction(currentUser, category);
        } else {
            action = new RenameCategoryAction(currentUser, currentUser.getCategory(category.getId()));
            currentUser.updateCategory(category);
        }
        undoManager.addAction(action);
        saveUser();
    }

    /**
     * Adds or updates a {@code PaymentType} with new information. User {@code NO_ID} as the id when adding, or the
     * existing id when updating.
     *
     * @param paymentType the {@code PaymentType} to be added or updated.
     * @return the added or updated {@code PaymentType}.
     * @throws BadPaymentTypeNameException when another payment type with the same name exists.
     */
    public void updatePaymentType(PaymentType paymentType) throws BadPaymentTypeNameException {
        logger.debug("updatePaymentType: {}", paymentType);
        Action action;
        boolean isNew = paymentType.getId() == User.NO_ID;
        if (isNew) {
            paymentType = currentUser.updatePaymentType(paymentType);
            action = new AddPaymentTypeAction(currentUser, paymentType);
        } else {
            action = new RenamePaymentTypeAction(currentUser, currentUser.getPaymentType(paymentType.getId()));
            currentUser.updatePaymentType(paymentType);
        }
        undoManager.addAction(action);
        saveUser();

    }

    /**
     * Adds or updates a {@code Expense} with new information. User {@code NO_ID} as the id when adding, or the existing
     * id when updating.
     *
     * @param expense the {@code Expense} to be added or updated.
     */
    public void updateExpense(Expense expense) {
        logger.debug("updateExpense: {}", expense);
        boolean isNew = expense.getId() == User.NO_ID;
        if (isNew) {
            expense = currentUser.updateExpense(expense);
            undoManager.addAction(new AddExpenseAction(currentUser, expense));
        } else {
            undoManager.addAction(new ModifyExpenseAction(currentUser, currentUser.getExpense(expense.getId())));
            currentUser.updateExpense(expense);
        }
        saveUser();
    }

    /**
     * Removes the category with the specified id.
     *
     * @param id the id of the category to remove.
     */
    public void deleteCategory(int id) {
        logger.debug("deleteCategory: {}", id);
        Category categoryToDelete = currentUser.getCategory(id);
        List<Expense> affectedExpenses = getExpenses(new CategoryExpenseFilter(currentUser.getCategory(id)));
        undoManager.addAction(new DeleteCategoryAction(currentUser, categoryToDelete, affectedExpenses));

        // Set affected expenses into uncategorised category
        for (Expense e : affectedExpenses) {
            e.setCategory(currentUser.getCategory(User.UNCATEGORISED_ID));
        }
        // Remove the category
        currentUser.removeCategory(id);
        saveUser();
    }

    /**
     * Removes the paymentType with the specified id.
     *
     * @param id the id of the paymentType to remove.
     */
    public void deletePaymentType(int id) {
        logger.debug("deletePaymentType: {}", id);
        PaymentType paymentTypeToDelete = currentUser.getPaymentType(id);
        List<Expense> affectedExpenses = getExpenses(new PaymentTypeExpenseFilter(currentUser.getPaymentType(id)));
        undoManager.addAction(new DeletePaymentTypeAction(currentUser, paymentTypeToDelete, affectedExpenses));

        // Set affected expenses as unspecified
        for (Expense e : affectedExpenses) {
            e.setPaymentType(currentUser.getPaymentType(User.UNSPECIFIED_PAYMENT_TYPE_ID));
        }
        // Remove the payment type
        currentUser.removePaymentType(id);
        saveUser();
    }

    /**
     * Removes the expense with the specified id.
     *
     * @param id the id of the expense to remove.
     */
    public void deleteExpense(int id) {
        logger.debug("deleteExpense: {}", id);
        undoManager.addAction(new DeleteExpenseAction(currentUser, currentUser.getExpense(id)));
        currentUser.removeExpense(id);
        saveUser();
    }

    /**
     * Generates an expense report.
     * @param type the type of report to generate
     * @param startDate the starting date of the report.
     * @return the generated report.
     */
    public Report generateReport(ReportType type, Date startDate) {
        logger.debug("generateReport: {}", type);
        List<Expense> expenses = getExpenses(new DateExpenseFilter(startDate, ReportService.getEndDateForPeriod(
                startDate, type)));
        return reportService.generateReport(type, expenses);
    }

    /**
     * Undoes the last action.
     */
    public void undo() {
        logger.debug("undo");
        undoManager.undo();
        saveUser();
    }

    /**
     * Checks whether there is anything to undo.
     *
     * @return whether there is anything to undo.
     */
    public boolean canUndo() {
        return undoManager.canUndo();
    }

    /**
     * Gets the current status.
     *
     * @return the current status string.
     */
    public String getStatus() {
        return undoManager.getStatus();
    }
}
